Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 350b95c10b0b3c0c041b8c89a7f1f1c98a85ac70


Parents : 729b7f2
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-17T19:38:18-05:00

feat: harden memory management and audio handling with new limits and warnings

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 2374532e..0deb2bf4 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 0701ef48..172bcab2 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7640,7 +7640,8 @@ class ReticulumMeshChat:
@routes.get("/ws/telephone/audio")
async def telephone_audio_ws(request):
websocket_response = web.WebSocketResponse(
- max_msg_size=5 * 1024 * 1024,
+ # Cap well above a normal PCM frame (tens of KB) but far below prior 5 MiB.
+ max_msg_size=256 * 1024,
)
await websocket_response.prepare(request)
@@ -10665,7 +10666,11 @@ class ReticulumMeshChat:
# get path params
identity_hash_hex = request.match_info.get("identity_hash", "")
- timeout_seconds = int(request.query.get("timeout", 15))
+ try:
+ timeout_seconds = int(request.query.get("timeout", 15))
+ except (TypeError, ValueError):
+ timeout_seconds = 15
+ timeout_seconds = max(1, min(timeout_seconds, 120))
try:
# convert hash to bytes
@@ -14941,16 +14946,55 @@ class ReticulumMeshChat:
# handle image
if attachment_type == "image" and "image" in fields:
- image_data = base64.b64decode(fields["image"]["image_bytes"])
+ image_field = fields["image"]
+ if not isinstance(image_field, dict):
+ return web.json_response(
+ {"message": "Invalid image attachment"},
+ status=400,
+ )
+ image_bytes_b64 = image_field.get("image_bytes")
+ if not isinstance(image_bytes_b64, str) or not image_bytes_b64:
+ return web.json_response(
+ {"message": "Missing image data"},
+ status=400,
+ )
+ try:
+ image_data = base64.b64decode(image_bytes_b64)
+ except Exception:
+ return web.json_response(
+ {"message": "Invalid image data"},
+ status=400,
+ )
allowed_image_types = {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
- image_type = fields["image"]["image_type"]
- if image_type.lower() not in allowed_image_types:
+ image_type = image_field.get("image_type") or "png"
+ if not isinstance(image_type, str):
+ image_type = "png"
+ image_type = image_type.lower().replace("image/", "").strip() or "png"
+ if image_type not in allowed_image_types:
image_type = "png"
return web.Response(body=image_data, content_type=f"image/{image_type}")
# handle audio
if attachment_type == "audio" and "audio" in fields:
- audio_data = base64.b64decode(fields["audio"]["audio_bytes"])
+ audio_field = fields["audio"]
+ if not isinstance(audio_field, dict):
+ return web.json_response(
+ {"message": "Invalid audio attachment"},
+ status=400,
+ )
+ audio_bytes_b64 = audio_field.get("audio_bytes")
+ if not isinstance(audio_bytes_b64, str) or not audio_bytes_b64:
+ return web.json_response(
+ {"message": "Missing audio data"},
+ status=400,
+ )
+ try:
+ audio_data = base64.b64decode(audio_bytes_b64)
+ except Exception:
+ return web.json_response(
+ {"message": "Invalid audio data"},
+ status=400,
+ )
return web.Response(
body=audio_data,
content_type="application/octet-stream",
@@ -14966,10 +15010,38 @@ class ReticulumMeshChat:
{"message": "Invalid file index"},
status=400,
)
- file_attachment = fields["file_attachments"][index]
- file_data = base64.b64decode(file_attachment["file_bytes"])
+ file_attachments = fields["file_attachments"]
+ if not isinstance(file_attachments, list) or index >= len(
+ file_attachments,
+ ):
+ return web.json_response(
+ {"message": "Invalid file index"},
+ status=400,
+ )
+ file_attachment = file_attachments[index]
+ if not isinstance(file_attachment, dict):
+ return web.json_response(
+ {"message": "Invalid file attachment"},
+ status=400,
+ )
+ file_bytes_b64 = file_attachment.get("file_bytes")
+ if not isinstance(file_bytes_b64, str) or not file_bytes_b64:
+ return web.json_response(
+ {"message": "Missing file data"},
+ status=400,
+ )
+ try:
+ file_data = base64.b64decode(file_bytes_b64)
+ except Exception:
+ return web.json_response(
+ {"message": "Invalid file data"},
+ status=400,
+ )
+ raw_name = file_attachment.get("file_name") or "download"
+ if not isinstance(raw_name, str):
+ raw_name = "download"
safe_name = (
- os.path.basename(file_attachment["file_name"])
+ os.path.basename(raw_name)
.replace('"', "_")
.replace("\r", "")
.replace("\n", "")

diff --git a/meshchatx/src/backend/recovery/health_monitor.py b/meshchatx/src/backend/recovery/health_monitor.py
index f26c3cb6..fcb7144c 100644
--- a/meshchatx/src/backend/recovery/health_monitor.py
+++ b/meshchatx/src/backend/recovery/health_monitor.py
@@ -10,6 +10,8 @@ No database queries are made in the monitor loop - all reads come
from in-memory deques kept by PersistentLogHandler and psutil.
"""
+from __future__ import annotations
+
import asyncio
import collections
import gc
@@ -130,7 +132,7 @@ class HealthMonitor:
self._mem_available_history,
self.MEMORY_RECOVER_MB,
):
- self._recover_memory_pressure()
+ self._recover_memory_pressure(available_mb)
for w in warnings:
_log.warning("Health warning: %s", w["message"])
@@ -171,15 +173,26 @@ class HealthMonitor:
except Exception as exc:
_log.debug("Memory pressure cleanup failed: %s", exc)
- def _recover_memory_pressure(self) -> None:
+ def _recover_memory_pressure(self, available_mb: float | None = None) -> None:
self._memory_pressure_active = False
manager = getattr(self.app, "memory_pressure", None) if self.app else None
- if manager is None:
- return
- try:
- manager.on_memory_recovered()
- except Exception as exc:
- _log.debug("Memory pressure recovery failed: %s", exc)
+ if manager is not None:
+ try:
+ manager.on_memory_recovered()
+ except Exception as exc:
+ _log.debug("Memory pressure recovery failed: %s", exc)
+ value = round(float(available_mb), 1) if available_mb is not None else None
+ self._broadcast(
+ {
+ "kind": "memory_recovered",
+ "message": (
+ f"Available memory recovered: {value:.0f} MB"
+ if value is not None
+ else "Available memory recovered"
+ ),
+ "value": value,
+ },
+ )
def _broadcast(self, warning_data):
if not self.app:

diff --git a/meshchatx/src/backend/web_audio_bridge.py b/meshchatx/src/backend/web_audio_bridge.py
index 224f55d6..c674c129 100644
--- a/meshchatx/src/backend/web_audio_bridge.py
+++ b/meshchatx/src/backend/web_audio_bridge.py
@@ -23,6 +23,9 @@ def _log_debug(msg: str):
class WebAudioSource(LocalSource):
"""Injects PCM frames (int16 little-endian) received over websocket into the transmit mixer."""
+ # ~2.7s of 48 kHz mono int16; normal frames are tens of ms.
+ MAX_PCM_BYTES = 256 * 1024
+
def __init__(self, target_frame_ms: int, sink: Mixer):
self.target_frame_ms = target_frame_ms or 60
self.sink = sink
@@ -48,6 +51,14 @@ class WebAudioSource(LocalSource):
def push_pcm(self, pcm_bytes: bytes):
try:
+ if pcm_bytes is None:
+ return
+ if len(pcm_bytes) > self.MAX_PCM_BYTES:
+ RNS.log(
+ f"WebAudioSource: dropping oversized pcm frame ({len(pcm_bytes)} bytes)",
+ RNS.LOG_WARNING,
+ )
+ return
samples = (
np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
)

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index c2d4a9ad..0d2441c0 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -619,6 +619,14 @@ import Toast from "./Toast.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import PromptDialog from "./PromptDialog.vue";
import ToastUtils from "../js/ToastUtils";
+import {
+ CLIENT_HEAP_SAMPLE_INTERVAL_MS,
+ MEMORY_WARNING_TOAST_KEY,
+ evaluateClientHeapSample,
+ handleHealthWarningPayload,
+ markMemoryWarningDismissed,
+ showMemoryWarningToastIfNeeded,
+} from "../js/healthMemoryWarning.js";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
import LanguageSelector from "./LanguageSelector.vue";
@@ -999,6 +1007,8 @@ export default {
WebSocketConnection.on("disconnected", this.onWsShellDisconnected);
WebSocketConnection.on("connected", this.onWsShellConnected);
this.registerShellWsHandlers();
+ this.startClientHeapMemoryWatch();
+ GlobalEmitter.on("toast-dismissed", this.onToastDismissedShell);
GlobalEmitter.on("identity-switching-start", this.onIdentitySwitchingStartShell);
GlobalEmitter.on("identity-switched-apply", this.onIdentitySwitchedApplyShell);
GlobalEmitter.on("sync-propagation-node", this.onSyncPropagationNodeShell);
@@ -1059,11 +1069,43 @@ export default {
this.startShellPollIntervals();
}
},
+ onToastDismissedShell({ key }) {
+ if (key === MEMORY_WARNING_TOAST_KEY) {
+ markMemoryWarningDismissed();
+ }
+ },
+ startClientHeapMemoryWatch() {
+ this.stopClientHeapMemoryWatch();
+ this._clientHeapMemoryTimer = setInterval(() => {
+ this.sampleClientHeapMemory();
+ }, CLIENT_HEAP_SAMPLE_INTERVAL_MS);
+ this.sampleClientHeapMemory();
+ },
+ stopClientHeapMemoryWatch() {
+ if (this._clientHeapMemoryTimer != null) {
+ clearInterval(this._clientHeapMemoryTimer);
+ this._clientHeapMemoryTimer = null;
+ }
+ },
+ sampleClientHeapMemory() {
+ let memoryInfo = null;
+ try {
+ memoryInfo = performance?.memory ?? null;
+ } catch {
+ memoryInfo = null;
+ }
+ const result = evaluateClientHeapSample(memoryInfo);
+ if (result.shouldWarn) {
+ showMemoryWarningToastIfNeeded(ToastUtils, { fromClientHeap: true });
+ }
+ },
stopShell() {
if (!this.shellRunning) {
return;
}
this.shellRunning = false;
+ this.stopClientHeapMemoryWatch();
+ GlobalEmitter.off("toast-dismissed", this.onToastDismissedShell);
clearInterval(this.reloadInterval);
this.reloadInterval = null;
clearInterval(this.appInfoInterval);
@@ -1575,6 +1617,9 @@ export default {
ToastUtils.warning(json.issues.join(" ") || "Database issue detected.", 8000);
}
},
+ health_warning: (json) => {
+ handleHealthWarningPayload(json, ToastUtils);
+ },
identity_switched: async (json) => {
await this.applyIdentitySwitched(json);
},

diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index d2297f72..b3f7d189 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -159,6 +159,9 @@ export default {
clearTimeout(toast.timer);
}
this.toasts.splice(index, 1);
+ if (toast.key != null) {
+ GlobalEmitter.emit("toast-dismissed", { key: toast.key });
+ }
}
},
toastClass(type) {

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 49197ecd..092c6841 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1758,6 +1758,12 @@
import Utils from "../../js/Utils";
import { copyTextToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
import { MESSAGE_BODY_MAX_DISPLAY_CHARS, isStringTooLargeForInlineDisplay } from "../../js/messageDisplayLimits.js";
+import {
+ MAX_CODEC2_DECODED_RAW_BYTES,
+ MAX_CODEC2_ENCODED_BYTES,
+ MAX_CODEC2_WAV_BYTES,
+ assertByteLengthAtMost,
+} from "../../js/codec2DecodeLimits.js";
import { buildTimestampGroupedOldestFirst } from "../../js/messageTimestampGrouping.js";
import DownloadUtils from "../../js/DownloadUtils";
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
@@ -5359,12 +5365,19 @@ export default {
} else {
encoded = new Uint8Array(audioBytes);
}
+ encoded = assertByteLengthAtMost(encoded, MAX_CODEC2_ENCODED_BYTES);
// decode codec2 audio
- const decoded = await Codec2Lib.runDecode(codecMode, encoded);
+ const decoded = assertByteLengthAtMost(
+ await Codec2Lib.runDecode(codecMode, encoded),
+ MAX_CODEC2_DECODED_RAW_BYTES
+ );
// convert decoded codec2 to wav audio
- const wavAudio = await Codec2Lib.rawToWav(decoded);
+ const wavAudio = assertByteLengthAtMost(
+ await Codec2Lib.rawToWav(decoded),
+ MAX_CODEC2_WAV_BYTES
+ );
// create blob from wav audio
const blob = new Blob([wavAudio], {
@@ -7023,10 +7036,17 @@ export default {
// decode codec2 audio back to wav so we can show a preview audio player before user sends it
const codec2Mode = this.audioAttachmentMicrophoneRecorder.codec2Mode;
- const decoded = await Codec2Lib.runDecode(codec2Mode, new Uint8Array(audio));
+ const encoded = assertByteLengthAtMost(new Uint8Array(audio), MAX_CODEC2_ENCODED_BYTES);
+ const decoded = assertByteLengthAtMost(
+ await Codec2Lib.runDecode(codec2Mode, encoded),
+ MAX_CODEC2_DECODED_RAW_BYTES
+ );
// convert decoded codec2 to wav audio and create a blob
- const wavAudio = await Codec2Lib.rawToWav(decoded);
+ const wavAudio = assertByteLengthAtMost(
+ await Codec2Lib.rawToWav(decoded),
+ MAX_CODEC2_WAV_BYTES
+ );
const wavBlob = new Blob([wavAudio], {
type: "audio/wav",
});

diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index 0e771445..49f88721 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -285,7 +285,12 @@ export default {
);
} catch (error) {
console.error("Error rendering micron:", error);
- this.renderedContent = `<p style="color: red;">Error rendering: ${error.message}</p>`;
+ const msg = String(error?.message ?? error ?? "unknown error")
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;");
+ this.renderedContent = `<p style="color: red;">Error rendering: ${msg}</p>`;
}
},
toggleView() {

diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 00ecd0f9..2ea5fbe5 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -78,17 +78,24 @@ export default class MicronParser extends BaseMicronParser {
if (typeof html !== "string") return html;
const dangerousProps = ["zindex", "inset", "top", "left", "right", "bottom", "transform"];
return html.replace(/(\s)style="([^"]*)"/g, (match, space, styleValue) => {
- const declarations = styleValue.split(";").filter(Boolean);
+ // Strip CSS comments so position/**/:fixed cannot hide the colon.
+ const cleaned = String(styleValue).replace(/\/\*[\s\S]*?\*\//g, "");
+ const declarations = cleaned.split(";").filter(Boolean);
const safe = declarations.filter((decl) => {
const colon = decl.indexOf(":");
if (colon <= 0) return false;
const rawProp = decl.slice(0, colon).trim();
- const prop = rawProp.toLowerCase().replace(/-/g, "");
+ const prop = rawProp.toLowerCase().replace(/-/g, "").replace(/\s+/g, "");
+ // Drop !important so "fixed !important" still matches fixed/sticky.
const val = decl
.slice(colon + 1)
.trim()
- .toLowerCase();
- if (prop === "position" && (val === "fixed" || val === "sticky")) return false;
+ .toLowerCase()
+ .replace(/!important/g, "")
+ .trim();
+ if (prop === "position" && (/\bfixed\b/.test(val) || /\bsticky\b/.test(val))) {
+ return false;
+ }
if (dangerousProps.includes(prop)) return false;
if (prop === "width" && /100v[wh]/.test(val)) return false;
if (prop === "height" && /100v[hw]/.test(val)) return false;

diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js
index 6d2ca149..292066e1 100644
--- a/meshchatx/src/frontend/js/NomadPageRenderer.js
+++ b/meshchatx/src/frontend/js/NomadPageRenderer.js
@@ -1,5 +1,6 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
+import MicronParser from "./MicronParser.js";
marked.setOptions({
gfm: true,
@@ -66,11 +67,28 @@ export function rewriteCssBodyHtmlSelectors(css) {
return s;
}
+export function stripOverlayFromCss(css) {
+ if (!css) {
+ return "";
+ }
+ let s = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
+ s = s.replace(/position\s*:\s*[^;{}]+/gi, (decl) => {
+ const lower = decl.toLowerCase();
+ if (/\bfixed\b/.test(lower) || /\bsticky\b/.test(lower)) {
+ return "position:static";
+ }
+ return decl;
+ });
+ s = s.replace(/\b(?:z-index|inset|top|left|right|bottom|transform)\s*:\s*[^;{}]+/gi, "");
+ s = s.replace(/\b(?:width|height)\s*:\s*[^;{}]*100v[wh][^;{}]*/gi, "");
+ return s;
+}
+
export function stripExternalFromCss(css) {
if (!css) {
return "";
}
- let s = css;
+ let s = stripOverlayFromCss(css);
s = s.replace(/@import\s+[^;]+;/gi, "");
s = s.replace(/@import\s+url\s*\([^)]+\)\s*;?/gi, "");
s = s.replace(/expression\s*\(/gi, "blocked(");
@@ -234,10 +252,11 @@ function basePurifyConfig() {
export function sanitizeNomadHtmlFragment(html) {
ensureNomadPurifyHooks();
- return DOMPurify.sanitize(html, {
+ const sanitized = DOMPurify.sanitize(html, {
...basePurifyConfig(),
WHOLE_DOCUMENT: false,
});
+ return MicronParser.stripOverlayStyles(sanitized);
}
export function sanitizeNomadHtmlDocument(html) {
@@ -258,10 +277,11 @@ export function sanitizeNomadHtmlDocument(html) {
bodyMarkup = html;
}
const wrapped = `<div class="${NOMAD_HTML_ROOT_CLASS}">${bodyMarkup}</div>`;
- return DOMPurify.sanitize(wrapped, {
+ const sanitized = DOMPurify.sanitize(wrapped, {
...basePurifyConfig(),
WHOLE_DOCUMENT: false,
});
+ return MicronParser.stripOverlayStyles(sanitized);
}
export function renderNomadMarkdown(markdown, options = {}) {

diff --git a/meshchatx/src/frontend/js/codec2DecodeLimits.js b/meshchatx/src/frontend/js/codec2DecodeLimits.js
new file mode 100644
index 00000000..2e075119
--- /dev/null
+++ b/meshchatx/src/frontend/js/codec2DecodeLimits.js
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: 0BSD
+
+/** Caps for Codec2 decode expansion (voice notes). Encoded LXMF audio is small; PCM/WAV is not. */
+export const MAX_CODEC2_ENCODED_BYTES = 512 * 1024;
+/** ~8 minutes of 8 kHz mono 16-bit PCM before WAV wrap. */
+export const MAX_CODEC2_DECODED_RAW_BYTES = 8 * 1024 * 1024;
+/** WAV header + PCM. Slightly above raw cap. */
+export const MAX_CODEC2_WAV_BYTES = MAX_CODEC2_DECODED_RAW_BYTES + 44;
+
+/**
+ * @param {ArrayBuffer | Uint8Array | null | undefined} data
+ * @param {number} maxBytes
+ * @returns {Uint8Array}
+ */
+export function assertByteLengthAtMost(data, maxBytes) {
+ if (data == null) {
+ throw new Error("Missing audio data");
+ }
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
+ if (bytes.byteLength > maxBytes) {
+ throw new Error(`Audio exceeds size limit (${bytes.byteLength} > ${maxBytes})`);
+ }
+ return bytes;
+}

diff --git a/meshchatx/src/frontend/js/healthMemoryWarning.js b/meshchatx/src/frontend/js/healthMemoryWarning.js
new file mode 100644
index 00000000..b8dc0193
--- /dev/null
+++ b/meshchatx/src/frontend/js/healthMemoryWarning.js
@@ -0,0 +1,149 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Session-scoped high-memory warning toast helpers.
+ *
+ * Host RAM pressure arrives via WebSocket health_warning (kind memory_low).
+ * Client JS heap pressure is sampled locally when performance.memory exists.
+ * Toast is sticky (duration 0) and shown at most once per pressure episode.
+ */
+
+export const MEMORY_WARNING_TOAST_KEY = "health-memory-warning";
+export const MEMORY_WARNING_MESSAGE_KEY = "app.memory_pressure_warning";
+export const CLIENT_HEAP_RATIO_THRESHOLD = 0.85;
+export const CLIENT_HEAP_CONSECUTIVE_NEEDED = 2;
+export const CLIENT_HEAP_SAMPLE_INTERVAL_MS = 30000;
+
+let dismissedThisEpisode = false;
+let toastVisible = false;
+let consecutiveHighHeap = 0;
+
+export function resetMemoryWarningStateForTests() {
+ dismissedThisEpisode = false;
+ toastVisible = false;
+ consecutiveHighHeap = 0;
+}
+
+function warningDataFromPayload(payload) {
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+ if (payload.data && typeof payload.data === "object") {
+ return payload.data;
+ }
+ return payload;
+}
+
+/**
+ * @param {unknown} payload
+ * @returns {boolean}
+ */
+export function isMemoryHealthWarningPayload(payload) {
+ const data = warningDataFromPayload(payload);
+ return Boolean(data && data.kind === "memory_low");
+}
+
+/**
+ * @param {unknown} payload
+ * @returns {boolean}
+ */
+export function isMemoryRecoveredPayload(payload) {
+ const data = warningDataFromPayload(payload);
+ return Boolean(data && data.kind === "memory_recovered");
+}
+
+/**
+ * @param {{ fromHealthWs?: boolean, fromClientHeap?: boolean }} options
+ * @returns {boolean}
+ */
+export function shouldShowMemoryWarningToast(options = {}) {
+ if (dismissedThisEpisode || toastVisible) {
+ return false;
+ }
+ return Boolean(options.fromHealthWs || options.fromClientHeap);
+}
+
+export function markMemoryWarningShown() {
+ toastVisible = true;
+}
+
+export function markMemoryWarningDismissed() {
+ toastVisible = false;
+ dismissedThisEpisode = true;
+ consecutiveHighHeap = 0;
+}
+
+export function markMemoryWarningRecovered() {
+ dismissedThisEpisode = false;
+ toastVisible = false;
+ consecutiveHighHeap = 0;
+}
+
+/**
+ * @param {{ jsHeapSizeLimit?: number, usedJSHeapSize?: number } | null | undefined} memoryInfo
+ * @returns {{ shouldWarn: boolean, reason: string, ratio?: number, consecutive?: number }}
+ */
+export function evaluateClientHeapSample(memoryInfo) {
+ if (!memoryInfo || typeof memoryInfo.jsHeapSizeLimit !== "number" || memoryInfo.jsHeapSizeLimit <= 0) {
+ consecutiveHighHeap = 0;
+ return { shouldWarn: false, reason: "unavailable" };
+ }
+ const used = memoryInfo.usedJSHeapSize;
+ if (typeof used !== "number" || !Number.isFinite(used) || used < 0) {
+ consecutiveHighHeap = 0;
+ return { shouldWarn: false, reason: "invalid" };
+ }
+ const ratio = used / memoryInfo.jsHeapSizeLimit;
+ if (ratio < CLIENT_HEAP_RATIO_THRESHOLD) {
+ consecutiveHighHeap = 0;
+ return { shouldWarn: false, reason: "below_threshold", ratio };
+ }
+ consecutiveHighHeap += 1;
+ if (consecutiveHighHeap < CLIENT_HEAP_CONSECUTIVE_NEEDED) {
+ return {
+ shouldWarn: false,
+ reason: "need_consecutive",
+ ratio,
+ consecutive: consecutiveHighHeap,
+ };
+ }
+ return {
+ shouldWarn: true,
+ reason: "high_heap",
+ ratio,
+ consecutive: consecutiveHighHeap,
+ };
+}
+
+/**
+ * @param {{ warning: (message: string, duration?: number, key?: string | null) => void }} toastUtils
+ * @param {{ fromHealthWs?: boolean, fromClientHeap?: boolean }} options
+ * @returns {boolean} true when a toast was shown
+ */
+export function showMemoryWarningToastIfNeeded(toastUtils, options = {}) {
+ if (!shouldShowMemoryWarningToast(options)) {
+ return false;
+ }
+ if (!toastUtils || typeof toastUtils.warning !== "function") {
+ return false;
+ }
+ toastUtils.warning(MEMORY_WARNING_MESSAGE_KEY, 0, MEMORY_WARNING_TOAST_KEY);
+ markMemoryWarningShown();
+ return true;
+}
+
+/**
+ * @param {unknown} payload
+ * @param {{ warning: (message: string, duration?: number, key?: string | null) => void }} toastUtils
+ * @returns {"shown" | "recovered" | "ignored"}
+ */
+export function handleHealthWarningPayload(payload, toastUtils) {
+ if (isMemoryRecoveredPayload(payload)) {
+ markMemoryWarningRecovered();
+ return "recovered";
+ }
+ if (!isMemoryHealthWarningPayload(payload)) {
+ return "ignored";
+ }
+ return showMemoryWarningToastIfNeeded(toastUtils, { fromHealthWs: true }) ? "shown" : "ignored";
+}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 785b460e..7361c445 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -332,6 +332,7 @@
"restart_backend": "Backend neu starten",
"restart_backend_started": "Backend wird neu gestartet…",
"restart_backend_failed": "Backend konnte nicht neu gestartet werden",
+ "memory_pressure_warning": "Hoher Speicherverbrauch erkannt. Schließen Sie große Nomad-Seiten, Sprachnachrichten oder Downloads, falls möglich, und schließen Sie dann diese Warnung. Anhaltender Druck kann von feindlichen Peer-Inhalten stammen.",
"view_backend_logs": "Absturzprotokoll anzeigen",
"view_backend_logs_failed": "Kein Absturzprotokoll verfügbar",
"blackhole_integration_enabled": "Blackhole-Integration",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 5dc52b35..1a030afb 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -409,6 +409,7 @@
"network_recover_failed": "Could not recover the network stack. Check interfaces and try again.",
"restart_backend_started": "Restarting backend…",
"restart_backend_failed": "Could not restart backend",
+ "memory_pressure_warning": "High memory use detected. Close large Nomad pages, voice notes, or downloads if you can, then dismiss this warning. Persistent pressure can come from hostile peer content.",
"view_backend_logs": "View crash log",
"view_backend_logs_failed": "No crash log available",
"blackhole_integration_enabled": "Blackhole Integration",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 7702a022..bcb54f93 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -359,6 +359,7 @@
"restart_backend": "Reiniciar backend",
"restart_backend_started": "Reiniciando backend…",
"restart_backend_failed": "No se pudo reiniciar el backend",
+ "memory_pressure_warning": "Uso alto de memoria detectado. Cierre páginas Nomad grandes, notas de voz o descargas si puede, y luego cierre esta advertencia. La presión persistente puede proceder de contenido hostil de un peer.",
"view_backend_logs": "Ver registro de fallos",
"view_backend_logs_failed": "No hay registro de fallos disponible",
"blackhole_integration_enabled": "Integración de los agujeros negros",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 2a0da62a..0627bfe5 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -367,6 +367,7 @@
"restart_backend": "Käynnistä taustapalvelu uudelleen",
"restart_backend_started": "Käynnistetään palvelua...",
"restart_backend_failed": "Taustapalvelun Uudelleenkäynnistys epäonnistui",
+ "memory_pressure_warning": "Korkea muistinkäyttö havaittu. Sulje suuret Nomad-sivut, ääniviestit tai lataukset jos mahdollista, ja sulje sitten tämä varoitus. Jatkuva paine voi johtua vihamielisestä vertaissisällöstä.",
"view_backend_logs": "Näytä kaatumisloki",
"view_backend_logs_failed": "Kaatumisloki ei ole saatavilla",
"blackhole_integration_enabled": "Musta aukko",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index c19b2f0b..eedb38ce 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -359,6 +359,7 @@
"restart_backend": "Redémarrer le moteur",
"restart_backend_started": "Redémarrage du moteur…",
"restart_backend_failed": "Impossible de redémarrer le moteur",
+ "memory_pressure_warning": "Utilisation mémoire élevée détectée. Fermez les grandes pages Nomad, notes vocales ou téléchargements si possible, puis fermez cet avertissement. Une pression persistante peut venir d'un contenu pair hostile.",
"view_backend_logs": "Voir le journal de crash",
"view_backend_logs_failed": "Aucun journal de crash disponible",
"blackhole_integration_enabled": "Intégration du trou noir",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 7e3c2a39..366c69e6 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -359,6 +359,7 @@
"restart_backend": "Riavvia backend",
"restart_backend_started": "Riavvio del backend…",
"restart_backend_failed": "Impossibile riavviare il backend",
+ "memory_pressure_warning": "Rilevato uso elevato di memoria. Chiudi pagine Nomad grandi, note vocali o download se puoi, poi chiudi questo avviso. Una pressione persistente può derivare da contenuti peer ostili.",
"view_backend_logs": "Visualizza registro crash",
"view_backend_logs_failed": "Nessun registro crash disponibile",
"blackhole_integration_enabled": "Integrazione Blackhole",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index e1bc9872..67886e38 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -359,6 +359,7 @@
"restart_backend": "Backend herstarten",
"restart_backend_started": "Backend wordt herstart…",
"restart_backend_failed": "Backend kon niet worden herstart",
+ "memory_pressure_warning": "Hoog geheugengebruik gedetecteerd. Sluit grote Nomad-pagina's, spraakberichten of downloads als dat kan, en sluit daarna deze waarschuwing. Aanhoudende druk kan komen van vijandige peer-inhoud.",
"view_backend_logs": "Crashlog bekijken",
"view_backend_logs_failed": "Geen crashlog beschikbaar",
"blackhole_integration_enabled": "Integratie van zwart gat",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 6c5337b9..1c8d4ff6 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -332,6 +332,7 @@
"restart_backend": "Перезапустить сервер",
"restart_backend_started": "Перезапуск сервера…",
"restart_backend_failed": "Не удалось перезапустить сервер",
+ "memory_pressure_warning": "Обнаружено высокое использование памяти. По возможности закройте большие страницы Nomad, голосовые заметки или загрузки, затем закройте это предупреждение. Стойкое давление может быть вызвано враждебным содержимым узла.",
"view_backend_logs": "Просмотр журнала сбоев",
"view_backend_logs_failed": "Журнал сбоев недоступен",
"blackhole_integration_enabled": "Интеграция Blackhole",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 7aed01f8..de3de67f 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -359,6 +359,7 @@
"restart_backend": "重启后端",
"restart_backend_started": "正在重启后端…",
"restart_backend_failed": "无法重启后端",
+ "memory_pressure_warning": "检测到高内存占用。请尽量关闭大型 Nomad 页面、语音消息或下载,然后关闭此警告。持续高压可能来自恶意对等内容。",
"view_backend_logs": "查看崩溃日志",
"view_backend_logs_failed": "无可用崩溃日志",
"blackhole_integration_enabled": "黑洞集成",

diff --git a/tests/backend/test_health_monitor.py b/tests/backend/test_health_monitor.py
index ebca00b9..551a189b 100644
--- a/tests/backend/test_health_monitor.py
+++ b/tests/backend/test_health_monitor.py
@@ -118,6 +118,46 @@ class TestHealthMonitorDetection(unittest.TestCase):
mem_warnings = [c for c in calls if c["kind"] == "memory_low"]
self.assertEqual(len(mem_warnings), 1)
+ @patch("meshchatx.src.backend.recovery.health_monitor.psutil")
+ def test_memory_single_dip_is_false_positive(self, mock_psutil):
+ """One low reading alone must not warn (needs consecutive samples)."""
+ mem_mock = MagicMock()
+ mem_mock.available = 50 * 1024 * 1024
+ mock_psutil.virtual_memory.return_value = mem_mock
+ with patch.object(self.monitor, "_broadcast") as mock_bc:
+ self.monitor._check()
+ calls = [c[0][0] for c in mock_bc.call_args_list]
+ mem_warnings = [c for c in calls if c["kind"] == "memory_low"]
+ self.assertEqual(len(mem_warnings), 0)
+
+ @patch("meshchatx.src.backend.recovery.health_monitor.psutil")
+ def test_memory_recovered_broadcast_after_pressure(self, mock_psutil):
+ mem_mock = MagicMock()
+ mem_mock.available = 500 * 1024 * 1024
+ mock_psutil.virtual_memory.return_value = mem_mock
+ self.monitor._memory_pressure_active = True
+ self.monitor._mem_available_history.append(450.0)
+ with patch.object(self.monitor, "_broadcast") as mock_bc:
+ self.monitor._check()
+ calls = [c[0][0] for c in mock_bc.call_args_list]
+ recovered = [c for c in calls if c["kind"] == "memory_recovered"]
+ self.assertEqual(len(recovered), 1)
+ self.assertFalse(self.monitor._memory_pressure_active)
+
+ @patch("meshchatx.src.backend.recovery.health_monitor.psutil")
+ def test_memory_still_low_does_not_broadcast_recovered(self, mock_psutil):
+ mem_mock = MagicMock()
+ mem_mock.available = 50 * 1024 * 1024
+ mock_psutil.virtual_memory.return_value = mem_mock
+ self.monitor._memory_pressure_active = True
+ self.monitor._mem_available_history.append(80.0)
+ with patch.object(self.monitor, "_broadcast") as mock_bc:
+ self.monitor._check()
+ calls = [c[0][0] for c in mock_bc.call_args_list]
+ recovered = [c for c in calls if c["kind"] == "memory_recovered"]
+ self.assertEqual(len(recovered), 0)
+ self.assertTrue(self.monitor._memory_pressure_active)
+
def test_latest_snapshot_structure(self):
self.monitor._check()
snap = self.monitor.latest_snapshot

diff --git a/tests/backend/test_web_audio_bridge.py b/tests/backend/test_web_audio_bridge.py
index 66bb10ec..08c68462 100644
--- a/tests/backend/test_web_audio_bridge.py
+++ b/tests/backend/test_web_audio_bridge.py
@@ -40,6 +40,13 @@ def test_web_audio_source_empty_pcm_does_not_push():
assert len(sink.frames) == 0
+def test_web_audio_source_drops_oversized_pcm_frame():
+ sink = _DummySink()
+ src = WebAudioSource(target_frame_ms=60, sink=sink)
+ src.push_pcm(b"\x00" * (WebAudioSource.MAX_PCM_BYTES + 1))
+ assert len(sink.frames) == 0
+
+
def test_web_audio_source_respects_sink_can_receive_false():
sink = MagicMock()
sink.can_receive.return_value = False

diff --git a/tests/frontend/MicronParser.test.js b/tests/frontend/MicronParser.test.js
index 04a88220..73734c64 100644
--- a/tests/frontend/MicronParser.test.js
+++ b/tests/frontend/MicronParser.test.js
@@ -558,6 +558,18 @@ Content at depth 1`;
});
describe("adversarial: XSS bypass attempts", () => {
+ it("strips position fixed even with !important", () => {
+ const html = MicronParser.stripOverlayStyles('<div style="position:fixed !important; color:red">x</div>');
+ expect(html.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ expect(html).toContain("color:red");
+ });
+
+ it("strips position fixed hidden by CSS comments", () => {
+ const html = MicronParser.stripOverlayStyles('<div style="position/**/:fixed; color:blue">x</div>');
+ expect(html.toLowerCase()).not.toMatch(/position/);
+ expect(html).toContain("color:blue");
+ });
+
it("blocks SVG onload XSS", () => {
const markup = '<svg onload="alert(1)">';
const html = parser.convertMicronToHtml(markup);

diff --git a/tests/frontend/NomadPageRenderer.security.test.js b/tests/frontend/NomadPageRenderer.security.test.js
index 28fa313c..e295b3fb 100644
--- a/tests/frontend/NomadPageRenderer.security.test.js
+++ b/tests/frontend/NomadPageRenderer.security.test.js
@@ -6,6 +6,7 @@ import {
rewriteCssBodyHtmlSelectors,
sanitizeNomadHtmlFragment,
stripExternalFromCss,
+ stripOverlayFromCss,
} from "../../meshchatx/src/frontend/js/NomadPageRenderer";
function assertNoDangerousHtmlPatterns(html) {
@@ -43,6 +44,15 @@ describe("NomadPageRenderer stripExternalFromCss", () => {
expect(stripExternalFromCss(`x{foo:javascript:alert(1)}`)).toContain("blocked:");
});
+ it("neutralises fixed/sticky overlays in css", () => {
+ expect(stripOverlayFromCss(`.x{position:fixed;inset:0;z-index:99999}`).toLowerCase()).not.toMatch(
+ /position\s*:\s*fixed/
+ );
+ expect(stripExternalFromCss(`.x{position:sticky !important}`).toLowerCase()).not.toMatch(
+ /position\s*:\s*sticky/
+ );
+ });
+
it("fuzzing: stripExternalFromCss never throws", () => {
for (let i = 0; i < 300; i++) {
let s = "";
@@ -131,6 +141,22 @@ describe("NomadPageRenderer HTML document sanitization", () => {
expect(html).toContain(".nomad-html-root");
});
+ it("strips fixed overlay styles from html documents", () => {
+ const html = renderNomadHtmlPage(
+ '<body><div style="position:fixed !important; inset:0; z-index:99999">Fake login</div></body>'
+ );
+ expect(html.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ expect(html.toLowerCase()).not.toContain("inset");
+ expect(html).toContain("Fake login");
+ });
+
+ it("strips fixed overlays from style blocks", () => {
+ const html = renderNomadHtmlPage(
+ "<body><style>.x{position:fixed;inset:0;z-index:99999}</style><div class=\"x\">x</div></body>"
+ );
+ expect(html.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ });
+
it("adversarial templates do not throw and omit script-like vectors", () => {
const templates = [
"<svg onload=alert(1)></svg>",

diff --git a/tests/frontend/Toast.test.js b/tests/frontend/Toast.test.js
index 8d3f7b48..5db03e04 100644
--- a/tests/frontend/Toast.test.js
+++ b/tests/frontend/Toast.test.js
@@ -79,6 +79,22 @@ describe("Toast.vue", () => {
expect(wrapper.text()).not.toContain("Test Message");
});
+ it("emits toast-dismissed with key when a keyed toast is closed", async () => {
+ const dismissed = vi.fn();
+ GlobalEmitter.on("toast-dismissed", dismissed);
+ GlobalEmitter.emit("toast", {
+ message: "Memory",
+ type: "warning",
+ duration: 0,
+ key: "health-memory-warning",
+ });
+ await wrapper.vm.$nextTick();
+ await wrapper.find("button").trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(dismissed).toHaveBeenCalledWith({ key: "health-memory-warning" });
+ GlobalEmitter.off("toast-dismissed", dismissed);
+ });
+
it("assigns correct classes for different toast types", async () => {
GlobalEmitter.emit("toast", { message: "Success", type: "success" });
GlobalEmitter.emit("toast", { message: "Error", type: "error" });

diff --git a/tests/frontend/codec2DecodeLimits.test.js b/tests/frontend/codec2DecodeLimits.test.js
new file mode 100644
index 00000000..c740d354
--- /dev/null
+++ b/tests/frontend/codec2DecodeLimits.test.js
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it } from "vitest";
+import {
+ MAX_CODEC2_DECODED_RAW_BYTES,
+ MAX_CODEC2_ENCODED_BYTES,
+ assertByteLengthAtMost,
+} from "../../meshchatx/src/frontend/js/codec2DecodeLimits.js";
+
+describe("codec2DecodeLimits", () => {
+ it("accepts buffers within caps", () => {
+ const small = new Uint8Array(16);
+ expect(assertByteLengthAtMost(small, MAX_CODEC2_ENCODED_BYTES)).toBe(small);
+ });
+
+ it("rejects oversized encoded and decoded buffers", () => {
+ expect(() => assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_ENCODED_BYTES + 1), MAX_CODEC2_ENCODED_BYTES)).toThrow(
+ /exceeds size limit/
+ );
+ expect(() =>
+ assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_DECODED_RAW_BYTES + 1), MAX_CODEC2_DECODED_RAW_BYTES)
+ ).toThrow(/exceeds size limit/);
+ });
+
+ it("rejects missing data", () => {
+ expect(() => assertByteLengthAtMost(null, 10)).toThrow(/Missing/);
+ });
+});

diff --git a/tests/frontend/healthMemoryWarning.test.js b/tests/frontend/healthMemoryWarning.test.js
new file mode 100644
index 00000000..c8d04229
--- /dev/null
+++ b/tests/frontend/healthMemoryWarning.test.js
@@ -0,0 +1,162 @@
+// SPDX-License-Identifier: 0BSD
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ CLIENT_HEAP_CONSECUTIVE_NEEDED,
+ CLIENT_HEAP_RATIO_THRESHOLD,
+ MEMORY_WARNING_MESSAGE_KEY,
+ MEMORY_WARNING_TOAST_KEY,
+ evaluateClientHeapSample,
+ handleHealthWarningPayload,
+ isMemoryHealthWarningPayload,
+ isMemoryRecoveredPayload,
+ markMemoryWarningDismissed,
+ markMemoryWarningRecovered,
+ markMemoryWarningShown,
+ resetMemoryWarningStateForTests,
+ shouldShowMemoryWarningToast,
+ showMemoryWarningToastIfNeeded,
+} from "@/js/healthMemoryWarning.js";
+
+describe("healthMemoryWarning", () => {
+ beforeEach(() => {
+ resetMemoryWarningStateForTests();
+ });
+
+ describe("payload classification (false positives)", () => {
+ it("rejects null and non-objects", () => {
+ expect(isMemoryHealthWarningPayload(null)).toBe(false);
+ expect(isMemoryHealthWarningPayload(undefined)).toBe(false);
+ expect(isMemoryHealthWarningPayload("memory_low")).toBe(false);
+ });
+
+ it("rejects entropy and error_rate warnings", () => {
+ expect(isMemoryHealthWarningPayload({ kind: "entropy_climbing" })).toBe(false);
+ expect(isMemoryHealthWarningPayload({ kind: "error_rate_high" })).toBe(false);
+ expect(
+ isMemoryHealthWarningPayload({
+ type: "health_warning",
+ data: { kind: "entropy_climbing" },
+ })
+ ).toBe(false);
+ });
+
+ it("accepts memory_low nested under data or flat", () => {
+ expect(isMemoryHealthWarningPayload({ kind: "memory_low" })).toBe(true);
+ expect(
+ isMemoryHealthWarningPayload({
+ type: "health_warning",
+ data: { kind: "memory_low", value: 42 },
+ })
+ ).toBe(true);
+ });
+
+ it("detects memory_recovered", () => {
+ expect(isMemoryRecoveredPayload({ kind: "memory_recovered" })).toBe(true);
+ expect(
+ isMemoryRecoveredPayload({
+ type: "health_warning",
+ data: { kind: "memory_recovered" },
+ })
+ ).toBe(true);
+ expect(isMemoryRecoveredPayload({ kind: "memory_low" })).toBe(false);
+ });
+ });
+
+ describe("one-time sticky toast gating", () => {
+ it("shows once for host memory_low then ignores until recovered", () => {
+ const toastUtils = { warning: vi.fn() };
+ expect(handleHealthWarningPayload({ data: { kind: "memory_low" } }, toastUtils)).toBe("shown");
+ expect(toastUtils.warning).toHaveBeenCalledWith(MEMORY_WARNING_MESSAGE_KEY, 0, MEMORY_WARNING_TOAST_KEY);
+
+ toastUtils.warning.mockClear();
+ expect(handleHealthWarningPayload({ data: { kind: "memory_low" } }, toastUtils)).toBe("ignored");
+ expect(toastUtils.warning).not.toHaveBeenCalled();
+
+ markMemoryWarningDismissed();
+ expect(handleHealthWarningPayload({ data: { kind: "memory_low" } }, toastUtils)).toBe("ignored");
+
+ expect(handleHealthWarningPayload({ data: { kind: "memory_recovered" } }, toastUtils)).toBe("recovered");
+ expect(handleHealthWarningPayload({ data: { kind: "memory_low" } }, toastUtils)).toBe("shown");
+ expect(toastUtils.warning).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores non-memory health kinds without toasting", () => {
+ const toastUtils = { warning: vi.fn() };
+ expect(handleHealthWarningPayload({ data: { kind: "entropy_climbing" } }, toastUtils)).toBe("ignored");
+ expect(handleHealthWarningPayload({ data: { kind: "error_rate_high" } }, toastUtils)).toBe("ignored");
+ expect(toastUtils.warning).not.toHaveBeenCalled();
+ });
+
+ it("shouldShowMemoryWarningToast blocks when visible or dismissed", () => {
+ expect(shouldShowMemoryWarningToast({ fromHealthWs: true })).toBe(true);
+ markMemoryWarningShown();
+ expect(shouldShowMemoryWarningToast({ fromHealthWs: true })).toBe(false);
+ markMemoryWarningDismissed();
+ expect(shouldShowMemoryWarningToast({ fromClientHeap: true })).toBe(false);
+ markMemoryWarningRecovered();
+ expect(shouldShowMemoryWarningToast({ fromClientHeap: true })).toBe(true);
+ });
+
+ it("showMemoryWarningToastIfNeeded no-ops without toast utils", () => {
+ expect(showMemoryWarningToastIfNeeded(null, { fromHealthWs: true })).toBe(false);
+ expect(showMemoryWarningToastIfNeeded({}, { fromHealthWs: true })).toBe(false);
+ });
+ });
+
+ describe("client heap false positives", () => {
+ it("does not warn when performance.memory is missing", () => {
+ expect(evaluateClientHeapSample(null)).toEqual({ shouldWarn: false, reason: "unavailable" });
+ expect(evaluateClientHeapSample({})).toEqual({ shouldWarn: false, reason: "unavailable" });
+ expect(evaluateClientHeapSample({ jsHeapSizeLimit: 0, usedJSHeapSize: 1 })).toEqual({
+ shouldWarn: false,
+ reason: "unavailable",
+ });
+ });
+
+ it("does not warn on invalid used heap", () => {
+ expect(
+ evaluateClientHeapSample({
+ jsHeapSizeLimit: 100,
+ usedJSHeapSize: Number.NaN,
+ })
+ ).toEqual({ shouldWarn: false, reason: "invalid" });
+ });
+
+ it("does not warn below ratio threshold", () => {
+ const limit = 1000;
+ const used = Math.floor(limit * (CLIENT_HEAP_RATIO_THRESHOLD - 0.05));
+ const result = evaluateClientHeapSample({ jsHeapSizeLimit: limit, usedJSHeapSize: used });
+ expect(result.shouldWarn).toBe(false);
+ expect(result.reason).toBe("below_threshold");
+ });
+
+ it("requires consecutive high samples (single spike is false positive)", () => {
+ const limit = 1000;
+ const used = Math.ceil(limit * CLIENT_HEAP_RATIO_THRESHOLD);
+ const first = evaluateClientHeapSample({ jsHeapSizeLimit: limit, usedJSHeapSize: used });
+ expect(first.shouldWarn).toBe(false);
+ expect(first.reason).toBe("need_consecutive");
+ expect(first.consecutive).toBe(1);
+
+ if (CLIENT_HEAP_CONSECUTIVE_NEEDED > 1) {
+ const second = evaluateClientHeapSample({ jsHeapSizeLimit: limit, usedJSHeapSize: used });
+ expect(second.shouldWarn).toBe(true);
+ expect(second.reason).toBe("high_heap");
+ }
+ });
+
+ it("resets consecutive counter after a low sample", () => {
+ const limit = 1000;
+ const high = Math.ceil(limit * CLIENT_HEAP_RATIO_THRESHOLD);
+ const low = Math.floor(limit * 0.2);
+ evaluateClientHeapSample({ jsHeapSizeLimit: limit, usedJSHeapSize: high });
+ const afterLow = evaluateClientHeapSample({ jsHeapSizeLimit: limit, usedJSHeapSize: low });
+ expect(afterLow.shouldWarn).toBe(false);
+ expect(afterLow.reason).toBe("below_threshold");
+ const afterHighAgain = evaluateClientHeapSample({ jsHeapSizeLimit: limit, usedJSHeapSize: high });
+ expect(afterHighAgain.shouldWarn).toBe(false);
+ expect(afterHighAgain.reason).toBe("need_consecutive");
+ });
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────